[ 롤모임 운영일지 ] - 20. 말풍선 하나에 커밋 열네 개 — 9-patch를 재발명하다 만난 border-image
실시간 경매 채팅의 말풍선에 스킨을 입히는 기능을 만들었다. 디자이너(라기보단 그림 그려주는 모임원)가 만든 말풍선 SVG를 등록하면, 그 유저의 채팅 말풍선이 기본 색상 박스 대신 그 그림으로 렌더되는 기능이다. 요구사항을 말로 하면 한 줄이다 — “이미지를 텍스트 길이에 맞춰 늘려주세요.”
그 한 줄이 커밋 열네 개가 됐다. 이번 편은 시리즈에서 처음 다루는 순수 프론트엔드/CSS 이야기이고, 하이라이트는 캡(말풍선 양끝 조각)이 화면을 뒤덮을 때까지 스스로 커지던 순환 폭주 버그다.
시작 — 통짜 이미지를 늘리면 되겠지
첫 커밋은 플랫폼 관리자 전용 테스트 페이지였다. 실시간 연결 없이 SVG를 업로드하거나 코드를 붙여넣으면 채팅 말풍선 미리보기에 바로 반영되는 화면. 렌더링은 가장 단순한 방식으로 시작했다 — 에셋 한 장을 <img>로 넣고 object-fit으로 늘리기.
13분 만에 첫 번째 벽을 만났다. SVG는 자체적으로 preserveAspectRatio를 갖고 있어서, <img> + object-fit으로는 늘어나지 않고 원본 비율 그대로 중앙에 조그맣게 렌더된다. background-image + background-size: 100% 100%로 바꿔서 퍼센트 값을 강제로 먹이는 방식으로 우회했다.
이제 늘어나긴 하는데, 이번엔 찌그러졌다. 말풍선 SVG의 양끝은 둥근데, 세로로 긴 원본(viewBox 38×64 같은)을 가로로 넓게 늘리면 그 둥근 끝부분까지 같이 늘어나 왜곡된다. 당연한 결과다 — 늘어나야 하는 건 중간뿐인데, 이미지 전체를 늘리고 있었으니까.
게임 UI의 지혜 — 3조각으로 자르기
그래서 게임 UI에서 흔히 쓰는 9-patch(9-slice) 기법을 흉내 내기로 했다. 말풍선을 왼쪽 캡 / 중간 / 오른쪽 캡 세 조각으로 나눠 따로 등록받고, 양끝 캡은 원본 비율을 유지한 채 두고 중간만 가로로 스트레치하는 구조다.
중간 조각도 한 번 더 손봐야 했다. 중간 조각의 원본은 16×64짜리 얇은 세로 단면이라, 이걸 통째로 늘려도 역시 왜곡된다. 알고 보니 이 조각은 원래 이어붙여 쓰는 반복 타일 구조였다 — 높이만 맞추고(background-size: auto 100%) 가로로 반복(repeat-x)하도록 바꿨다.
여기까지 하고 나니 그럴듯해 보였다. 이날 오후 1시 56분에 테스트 페이지를 만들었고, 3시 7분에는 이걸 실제 스토어 기능으로 승격시켰다 — BubbleSkin(전역 카탈로그, 이모티콘 팩 전례를 따름) / BubbleSkinPurchase(모임별 구매 기록) 테이블을 만들고, 착용은 스키마에 예약만 돼 있고 안 쓰이던 UserCosmeticEquip(slot=BUBBLE_SKIN)을 재사용했다. 3분할 렌더링은 BubbleSkinBox라는 공용 컴포넌트로 추출해 관리자 미리보기·배팅상점·실시간 채팅 세 곳에서 같이 쓴다.
순환 의존과의 만남들
문제는 캡의 크기를 “텍스트에 맞추는” 부분이었다. 이날 오후에만 같은 뿌리의 문제를 세 번 다른 얼굴로 만났다.
1차 — 브라우저가 stretch를 포기했다. 캡 높이를 텍스트 박스에 맞추려고 flex의 align-items: stretch를 썼는데, 캡이 원본 크기 그대로 크게 렌더됐다. 모든 형제 요소의 높이가 서로에게 의존하는 순환 구조가 되자, 브라우저가 stretch 계산을 포기하고 이미지 원본 크기로 렌더해버린 것이다. ResizeObserver로 중간(텍스트) 박스의 실제 렌더링 높이를 측정해 캡 높이를 픽셀로 직접 지정하는 방식으로 바꿨다.
2차 — 말풍선이 채팅창 밖으로. 캡+중간이 전부 shrink-0이라, 전체 폭이 max-w-[80%]를 넘으면 캡이 줄지 않고 그냥 컨테이너 밖으로 삐져나갔다. 캡은 고정하되 중간만 줄어들 수 있게(min-w-0) 해서, 넘칠 것 같으면 텍스트가 먼저 줄바꿈되게 했다.
3차 — 캡이 화면을 뒤덮었다. 이날의 하이라이트다. 긴 메시지를 입력하면 캡이 걷잡을 수 없이 커지면서 화면을 뒤덮고, 텍스트는 한 글자씩 세로로 줄바꿈되는 기괴한 화면이 나왔다. 원인은 완벽한 양성 피드백 루프였다.
텍스트가 길어져 줄바꿈됨 → 박스 높이 증가 → 캡 높이도 증가 → (비율 유지라) 캡 폭도 증가 → 텍스트가 쓸 수 있는 폭 감소 → 줄바꿈이 더 심해짐 → 박스가 더 높아짐 → 캡이 더 커짐 → …
캡 높이를 중간 박스의 “실제 렌더링 높이(줄바꿈 포함)“로 측정한 게 화근이었다. 높이가 폭에 영향을 주고 폭이 다시 높이에 영향을 주는 고리를 만들어버린 것이다.
최종 해법은 4분 뒤 커밋에 정리돼 있다 — 폭과 높이의 의존 관계를 끊는다. 캡의 폭은 “한 줄 기준” 높이(getComputedStyle로 line-height+padding을 1회만 측정)에 이미지 원본 비율을 곱한 상수로 고정하고, 높이만 ResizeObserver로 실제 말풍선 높이를 따라가게 했다. 폭이 상수면 텍스트가 쓸 수 있는 폭도 상수라 줄바꿈 수가 안정적으로 정해지고, 순환의 고리가 끊긴다. 중간 조각이 이미 그렇게 동작하고 있었다 — 한쪽 치수만 고정하고, 다른 쪽만 스트레치. 이 원칙을 캡에도 적용한 것뿐이다.
닷새 뒤 — 이게 다 border-image였다
닷새 뒤, 새로운 왜곡이 눈에 들어왔다. 캡 이미지 자체에 상단 장식이나 하단 꼬리 같은 디테일이 있는 스킨은, 여러 줄 메시지로 캡이 세로로 늘어날 때 그 디테일까지 같이 찌그러졌다. 캡을 통짜로 세로 스트레치하고 있었으니까 — 가로 방향에서 겪은 문제를 세로 방향에서 그대로 다시 겪은 것이다.
그리고 이 문제를 풀면서, 처음부터 존재했던 표준을 만났다. CSS border-image. “가장자리는 고정하고 중간만 늘린다”는, 내가 하루 종일 손으로 재발명하던 바로 그 9-patch가 CSS 명세에 이미 들어 있다.
// packages/frontend/src/components/BubbleSkinBox.tsx
style={{
boxSizing: "border-box",
height: capHeightPx ? `${capHeightPx}px` : undefined,
width: leftWidth ? `${leftWidth}px` : undefined,
borderStyle: "solid",
borderColor: "transparent", // border-image 로드 실패 시 currentColor 통짜 막대가 뜨는 것 방지
borderWidth: `${capTop}px 0 ${capBottom}px 0`,
borderImageSource: `url(${leftUrl})`,
borderImageSlice: `${sliceTop} 0 ${sliceBottom} 0 fill`,
borderImageWidth: `${capTop}px 0 ${capBottom}px 0`,
borderImageRepeat: "stretch",
}}
BubbleSkin에 capSliceTopPx/capSliceBottomPx 필드(기본 26px)를 추가해서, 스킨마다 “위/아래 몇 px을 고정 영역으로 볼지”를 어드민 등록 화면에서 지정할 수 있게 했다. 캡의 꼬리·장식은 고정된 채 그 사이만 늘어난다.
디테일이 하나 더 있다. 렌더 높이가 슬라이스 합보다 작으면(짧은 한 줄 메시지 + 기본 26/26 조합에서 실제로 발생) box-sizing: border-box에서 border가 height를 넘어 넘쳐버린다. 그래서 border-width 쪽만 상/하 비율을 유지한 채 축소하고, slice(소스 좌표)는 그대로 둬서 브라우저가 알아서 스케일해 넣게 했다.
// packages/frontend/src/components/BubbleSkinBox.tsx
const sliceSum = sliceTop + sliceBottom;
const sliceScale = capHeightPx && sliceSum > 0 && sliceSum > capHeightPx ? capHeightPx / sliceSum : 1;
const capTop = Math.max(0, Math.round(sliceTop * sliceScale));
const capBottom = Math.max(0, Math.round(sliceBottom * sliceScale));
재발명이 전부 헛수고였냐 하면, 그건 아니다. 가로 방향의 3분할 구조(캡/반복 타일/캡)와 “한쪽 치수만 고정” 원칙은 최종 코드에 그대로 남아 있다. border-image가 대체한 건 캡 내부의 세로 스트레치뿐이다. 다만 첫날 이 속성을 알았다면 하루의 절반은 아꼈을 것이다.
남은 디테일 — 글자색은 스킨의 일부다
스킨을 입히고 나니 글자가 안 보이는 문제가 남았다. 스킨 배경은 어떤 색이든 될 수 있는데, 글자색은 기본색 그대로였다 — 어두운 스킨 위의 어두운 글자. 그래서 textColor를 스킨 등록 항목에 추가했다. 배경 이미지에 맞는 글자색을 정하는 건 코드가 아니라 스킨을 등록하는 사람의 몫이라는 판단이다.
이 작업 중에 부수적인 문제도 하나 발견했다. 공개 스킨 목록 API가 isPublic 스킨만 내려주고 있어서, 시즌패스 전용(비공개) 스킨을 착용한 유저의 메시지가 다른 사람 화면에서는 기본색으로 폴백되고 있었다. “구매 가능 여부”와 “렌더링 가능 여부”는 별개 개념인데 하나의 플래그로 겸하고 있었던 것 — 현재 시즌 보상에 연결된 스킨은 보유자와 무관하게 카탈로그에 포함하도록 고쳤다.
마지막 열네 번째 커밋은 그로부터 여드레 뒤였다. 배팅상점의 구매 카드 미리보기에 textColor를 안 넘기고 있어서, 스토어에서만 글자색이 누락돼 보이던 것. 공용 컴포넌트를 세 군데서 쓰면, prop 하나를 추가할 때 세 군데를 다 챙겨야 한다는 평범한 교훈이다.
정리
- “이미지를 텍스트에 맞춰 늘린다”는 요구사항의 본질은 9-patch였고, CSS에는 그게
border-image라는 이름으로 이미 있었다. 손으로 재발명한 하루가 아깝긴 하지만, 그 과정에서 왜 가장자리를 고정해야 하는지·왜 반복 타일이 필요한지를 몸으로 이해했기 때문에 border-image의 각 속성이 뭘 하는지 바로 읽혔다. - 레이아웃 순환 폭주는 “서로의 크기를 참조하는 고리”에서 나온다. 브라우저가 조용히 stretch를 포기하는 온건한 형태든, 캡이 화면을 뒤덮는 폭주 형태든 뿌리는 같았다. 해법도 같다 — 한쪽 치수를 상수로 고정해서 고리를 끊는다.
- 하나의 플래그에 두 가지 의미를 겸하게 하면 언젠가 갈라진다.
isPublic이 “구매 가능”과 “렌더링 가능”을 겸하다가, 비공개 보상 스킨이 남의 화면에서 깨지는 형태로 터졌다. - 순수 CSS 삽질처럼 보이는 작업도 기록해두니 남는 게 있다. 다음에 또 “이미지 늘리기”가 나오면 이 글부터 열어볼 것이다.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.